Skip to content

test: harden clear() namespace isolation, tag invalidation and deleteItem() return value - #124

Open
GromNaN wants to merge 6 commits into
php-cache:masterfrom
GromNaN:test/namespace-isolation-and-tag-invalidation
Open

test: harden clear() namespace isolation, tag invalidation and deleteItem() return value#124
GromNaN wants to merge 6 commits into
php-cache:masterfrom
GromNaN:test/namespace-isolation-and-tag-invalidation

Conversation

@GromNaN

@GromNaN GromNaN commented Aug 24, 2026

Copy link
Copy Markdown

Three small additions to the contract, found while implementing a PSR-6 adapter for MongoDB (symfony/symfony#65468). In each case the current tests pass on an implementation that is actually wrong.

clear() must not touch another namespace on the same storage

When several pools share a single storage, a table or a collection for instance, clearing one pool must only remove its own keys. Nothing checks that today, because the tests only ever use one pool.

CachePoolTest gains an optional createCachePoolWithOtherNamespace(), which returns a second pool on the same storage under another namespace. It returns null by default, and the new testClearDoesNotAffectOtherNamespaces is then skipped, so existing implementations are unaffected. When a pool is given, the test writes the same key in both, clears the first one, and checks that the second still holds its own value.

testInvalidateTags now uses disjoint tags

key was tagged with tag1 and tag2 while key2 was tagged with tag1, so invalidating tag1 alone was enough to pass. Each item now carries a single distinct tag, and a third item, tagged with none of the invalidated tags, checks that the invalidation does not reach too far.

deleteItem() return value on a tagged item

testRemoveTagWhenItemIsRemoved ignored what deleteItem() returned. It is now asserted to be true.

Add an optional createCachePoolWithOtherNamespace() extension point and a test that saves the same key in two pools sharing the same storage under different namespaces. The test is skipped when the implementation returns null.
The test used overlapping tags, so an implementation that only invalidates the first tag of the list still passed. Use disjoint tags plus a control item tagged with none of the invalidated tags.
The return value was ignored, so an implementation returning false while the deletion actually happened still passed.
@GromNaN
GromNaN force-pushed the test/namespace-isolation-and-tag-invalidation branch from 9f64b72 to e61cecb Compare August 24, 2026 09:59
@cryptiklemur

cryptiklemur commented Sep 2, 2026

Copy link
Copy Markdown
Member

Nice find on all three. Few things before I merge.

createCachePoolWithOtherNamespace() returns a second FilesystemAdapter, and FilesystemCommonTrait::init() does $directory .= \DIRECTORY_SEPARATOR.$namespace. Two separate directories, so the bug class this is aimed at, prefix collisions on one substrate, structurally can't happen there. Two PdoAdapters over one sqlite connection would bite: doClear() is DELETE ... WHERE id LIKE 'ns%', so the namespace is a real prefix in a shared table. That means moving createCachePool() too though. If you'd rather not, just say in the docblock that it's a smoke test.

Small one: $other->clear() on the last line only runs if every assertion above it passed. try/finally.

On testInvalidateTags, can you keep key tagged ['tag1', 'tag2'] alongside the new key2 and key3? It was the only spot in the suite where a single invalidateTags() call matched one item through two of the passed tags.

The final $other->clear() only ran when every assertion above it passed,
so a failure left the second namespace populated for the next test.
key carries tag1 and tag2 again, which is the only place in the suite
where one invalidateTags() call matches an item through two of the
passed tags. key2 carries tag2 alone and key3 carries tag1 alone, so
each invalidated tag is still required on its own. key4 carries none
of them and checks that the invalidation does not reach too far.
TaggableCachePoolTest gains the same optional
createCachePoolWithOtherNamespace() as CachePoolTest, returning null by
default so the test is skipped on implementations that cannot provide a
second pool.

The TaggableCachePool fixture now takes an optional shared storage and a
namespace, and wraps the storage in a ProxyAdapter. Both pools then sit
on one key space where the namespace is a real key prefix, so the test
can detect a tag invalidation that reaches across namespaces.
@GromNaN

GromNaN commented Sep 3, 2026

Copy link
Copy Markdown
Author

Thank you for this review, it is genuinely helpful. You read the actual mechanics of each adapter rather than just the diff, and the FilesystemCommonTrait::init() and PdoAdapter::doClear() details you pointed at are exactly the right ones to weigh here. I appreciate the care.

Points 2 and 3 are fixed.

$other->clear() is now in a finally, so the second pool gets cleaned up even when an assertion above it fails.

On testInvalidateTags, key is tagged ['tag1', 'tag2'] again, so the case where one item is matched through two of the passed tags is back. I kept the discrimination by splitting the rest: key2 carries tag2 alone and key3 carries tag1 alone, so each invalidated tag is now required on its own. key4 carries tag3 and checks that the invalidation does not reach too far.

On the namespace test, I would rather not call it a smoke test, because passing is the expected outcome for a correct implementation. FilesystemAdapter gives each namespace its own directory, so it cannot leak across namespaces, and the test says exactly that. The test is not there to fail on compliant adapters, it is there to fail on the ones that build a shared key space and then clear it with a prefix match that is too loose. That is the class of bug I hit while implementing the PSR-6 MongoDB adapter, where every pool shares one collection.

You are right that the suite here did not exercise a shared substrate, so nothing proved the test would catch a prefix collision. I addressed that on the taggable side, which is where I think it matters most: TaggableCachePoolTest gains the same optional createCachePoolWithOtherNamespace() and a testInvalidateTagsDoesNotAffectOtherNamespaces, and the TaggableCachePool fixture now takes an optional shared storage plus a namespace, so the two pools really do sit on one key space with the namespace as a key prefix. I checked it the other way round too: dropping the prefix from the fixture makes the new test fail, so it does detect the leak.

If you would also like the PSR-6 clear() case covered on a shared substrate, say the word and I will add a test class running the contract over two PdoAdapter instances on one sqlite connection, either here or as a follow up.

@cryptiklemur

Copy link
Copy Markdown
Member

The finally and the two-matching-tags case both look right now. And fair on "smoke test", that was a bad word for it.

The shared-storage fixture doesn't do what the docblock says, though. ArrayAdapter implements NamespacedPoolInterface, so ProxyAdapter::__construct takes the withSubNamespace() branch, clones the storage into a fresh store, and sets its own $namespace to ''. No prefix is ever applied.

Probe against the fixture exactly as the test builds it:

shared parent store keys: []
proxy A namespace: ''  proxy B namespace: ''
A inner === storage? false
A inner === B inner? false
A inner keys: ["key"]
B inner keys: ["key"]

Two pools, two separate stores. And dropping the prefix doesn't test the prefix: with '' the branch is skipped entirely, both proxies wrap the same bare ArrayAdapter, and both pools save under the key key. So B's save overwrites A's value before any invalidation runs. That's a key collision, not a tag leak.

Which means the new test can't catch a cross-namespace leak. I made invalidateTags() wipe the whole backing store and it still passed.

The fix is to wrap the storage in the ValidatingCachePool that's already in the fixtures, in both factory methods. It's PSR-6 only, so ProxyAdapter falls to the prefix branch:

return new TaggableCachePool(new Fixtures\ValidatingCachePool($this->sharedStorage()), 'first');
return new TaggableCachePool(new Fixtures\ValidatingCachePool($this->sharedStorage()), 'second');

TaggableCachePool::__construct then needs ?CacheItemPoolInterface instead of ?AdapterInterface. With that, both items land in one ArrayAdapter as firstkey and secondkey, the wipe-everything mutant goes red, and all three CI jobs pass locally: 386 tests / 761 assertions, phpstan clean, cs-check clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants